如果 blog-app 之後想同時服務中文跟英文讀者,多語系(Localization)就是今天的主題。這塊的核心 API 很穩固,唯一需要更新的是「用 Session + Middleware 持久化使用者語系」這個經典範例——中介層的註冊位置換了,今天改用 Day 16 學到的新寫法。
php artisan lang:publish
這個指令會產生 lang/ 目錄,放翻譯檔案。config/app.php 裡兩個相關設定:
'locale' => 'zh_TW', // 預設語系
'fallback_locale' => 'en', // 找不到對應翻譯時的備援語系
App::setLocale('en');
注意這個方法只在單次請求的生命週期內生效,不會自動記住使用者下次的選擇,需要自己決定持久化方式。三種常見做法:
php artisan make:middleware SetLocale
// app/Http/Middleware/SetLocale.php
class SetLocale
{
public function handle(Request $request, Closure $next): Response
{
if ($locale = $request->session()->get('locale')) {
App::setLocale($locale);
}
return $next($request);
}
}
註冊方式延續 Day 16 學到的寫法,加進 web 群組(舊寫法是插進 Kernel.php 的 $middlewareGroups['web']):
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
SetLocale::class,
]);
})
這裡要特別注意順序:SetLocale 必須排在 Session 相關的中介層(StartSession)之後執行,才能正確讀到 session() 裡的值。web 中介層群組本身已經包含 StartSession,用 append() 加進去的中介層預設會排在群組現有內容之後,符合這個順序要求;如果你改用 prepend() 插到最前面,反而會在 Session 啟動前就想讀取它,導致讀不到值。
切換語系的路由:
Route::post('/locale', function (Request $request) {
$request->session()->put('locale', $request->input('locale'));
return back();
})->name('locale.switch');
搭配一個簡單的語言切換器:
<form method="POST" action="{{ route('locale.switch') }}">
@csrf
<select name="locale" onchange="this.form.submit()">
<option value="zh_TW" @selected(app()->getLocale() === 'zh_TW')>繁體中文</option>
<option value="en" @selected(app()->getLocale() === 'en')>English</option>
</select>
</form>
@selected() 是 Blade 內建的條件式屬性指令(跟 Day 12 提過的 @class 是同一類設計),符合條件時自動輸出 selected 屬性,不需要自己手寫三元運算子拼接字串。
同樣寫成 Middleware,但改成從標頭判斷。這裡有個很多人踩過的細節:瀏覽器送出的 Accept-Language 不是單一語系代碼,而是一串帶權重的偏好清單(例如 zh-TW,zh;q=0.9,en-US;q=0.8),直接拿去比對是不會相等的。Laravel 提供 getPreferredLanguage() 幫你解析:
// app/Http/Middleware/SetLocale.php
public function handle(Request $request, Closure $next): Response
{
$locale = $request->getPreferredLanguage(['zh_TW', 'en']);
App::setLocale($locale ?? config('app.locale'));
return $next($request);
}
如果是純 API、由前端自己管理語系狀態的情境,更常見的做法是約定一個自訂標頭,語意比 Accept-Language 明確、也不會被瀏覽器行為干擾:
$locale = $request->header('X-App-Locale');
if (in_array($locale, ['zh_TW', 'en'], strict: true)) {
App::setLocale($locale);
}
注意這兩種寫法都放在 Middleware 而不是 AppServiceProvider::boot()——Provider 的 boot() 在每個請求都會執行沒錯,但在那個階段就去讀取請求物件,會讓「這段邏輯依賴 HTTP 請求」這件事藏在一個看起來與請求無關的地方,也讓 Console 指令、佇列這些沒有 HTTP 請求的執行情境變得難以推理。
把使用者偏好的語系存成 User 的一個欄位,登入時讀出來設定,適合「登入後偏好要跨裝置同步」的產品需求:
// app/Http/Middleware/SetLocale.php
public function handle(Request $request, Closure $next): Response
{
if ($request->user()?->locale) {
App::setLocale($request->user()->locale);
} elseif ($sessionLocale = $request->session()->get('locale')) {
App::setLocale($sessionLocale);
}
return $next($request);
}
實務上這三種方式常常混合使用:已登入使用者優先用資料庫存的偏好(跨裝置同步)、未登入訪客用 Session(單裝置內記住選擇)、Session 也沒有時退回瀏覽器 Accept-Language 偵測,這樣的層層 fallback 邏輯能兼顧不同情境下使用者的實際體驗。

App::currentLocale(); // 'zh_TW'
App::isLocale('zh_TW'); // true / false
多語系經常被誤解成「只要把文字換成對應語言」,但日期、數字的呈現格式同樣需要在地化。blog-app 顯示文章發布時間,如果服務多語系使用者,Carbon 提供對應的在地化格式:
$post->published_at->locale('zh_TW')->isoFormat('YYYY年M月D日 dddd'); // "2026年8月7日 星期五"
$post->published_at->locale('en')->isoFormat('MMMM D, YYYY'); // "August 7, 2026"
$post->published_at->diffForHumans(); // 自動依目前 App::getLocale() 呈現相對時間,例如「3 小時前」或「3 hours ago」
diffForHumans() 特別方便——它會自動讀取目前設定的語系,不需要每次呼叫都額外指定,這也是為什麼 Day 19 那種背景 Job 裡如果需要顯示時間給使用者看,記得考慮該用哪個語系的 Carbon 實例(Job 執行時的預設語系可能跟觸發它的那個 HTTP 請求不同,因為 Job 是在完全獨立的 Worker 行程裡執行,Middleware 設定的語系不會自動延續過去)。
翻譯檔案有兩種組織方式:
lang/
├── en.json
├── zh_TW.json
└── zh_TW/
└── validation.php
.json 格式適合零散的、不分類的字串(不支援巢狀結構):
// lang/zh_TW.json
{
"Welcome to :app": "歡迎來到 :app",
"Read more": "閱讀更多",
"This post has not been published yet.": "這篇文章尚未發布。"
}
{{ __('Welcome to :app', ['app' => 'blog-app']) }}
@lang('Welcome to :app', ['app' => 'blog-app'])
英文的「1 post / 2 posts」需要依數量切換詞形,用 | 分隔單複數,讀取時改用 trans_choice():
// lang/en.json
{
"{0} No posts|[1] :count post|[2,*] :count posts": "{0} No posts|[1] :count post|[2,*] :count posts"
}
{{ trans_choice('{0} No posts|[1] :count post|[2,*] :count posts', $posts->count(), ['count' => $posts->count()]) }}
中文沒有單複數變化,翻譯成中文時各區間寫同一種說法即可({0} 沒有文章|[1,*] :count 篇文章)——這正是為什麼多語系不能只做字串替換,語言的語法規則本身就不一樣。
有些語言的複數規則比英文複雜得多(例如波蘭文、阿拉伯文有超過兩種複數形式),Laravel 的 trans_choice() 底層支援 Symfony 的複數化規則,能正確處理這些語言,不需要你自己手動判斷每種語言各自的規則細節——只要照著 [0,1]/[2,*] 這種區間語法定義好對應每個數量範圍的文字即可。
帶入變數時如果變數本身要跟著大小寫轉換,Laravel 會自動處理::Username 會轉成首字大寫,:USERNAME 會轉成全大寫,方便翻譯字串套進不同大小寫語境而不用準備多份翻譯。
分類的翻譯檔案(例如驗證錯誤訊息,這個系列 Day 17 的 Validation 就會用到)用 .php 陣列格式:
// lang/zh_TW/validation.php
return [
'required' => ':attribute 欄位是必填的。',
'max' => [
'string' => ':attribute 不能超過 :max 個字元。',
],
];
如果同一個 key 同時出現在 .json 跟對應語系的 .php 檔案裡,.json 格式的優先權較高——這代表如果你想針對某個特定情境暫時覆蓋一個已經在 .php 檔案裡定義的翻譯,可以直接在 .json 檔案加一筆同名的覆蓋,不需要去改動原本結構化的 .php 檔案。這在維護大型翻譯字典、又偶爾需要做局部快速調整時提供了彈性,但團隊協作時建議約定清楚兩種格式各自的用途分工,避免同一個字串散落在兩種格式裡造成混亂。
test('文章列表頁在英文語系下顯示英文標題', function () {
$this->withHeaders(['Accept-Language' => 'en'])
->get('/posts')
->assertSee('Latest Posts');
});
test('trans_choice 正確處理中文的文章數量', function () {
App::setLocale('zh_TW');
expect(trans_choice('{0} 沒有文章|[1,*] :count 篇文章', 0))->toBe('沒有文章');
expect(trans_choice('{0} 沒有文章|[1,*] :count 篇文章', 5, ['count' => 5]))->toBe('5 篇文章');
});
SetLocale Middleware 排錯順序導致讀不到 Session:前面提過,務必確認排在 StartSession 之後,這是把舊範例改寫到新版註冊方式時最容易漏掉的細節。.json 翻譯檔想寫巢狀結構:.json 格式的翻譯檔不支援巢狀 key,如果需要分類組織,改用 lang/{語系}/{檔名}.php 陣列格式。App::setLocale() 呼叫的時機太晚:如果放在 Controller 方法裡才呼叫,這之前已經渲染或處理過的部分(例如某些 Middleware 產生的訊息)可能還是用舊語系,語系設定邏輯放進 Middleware(在請求生命週期早期執行)通常比放在 Controller 裡更可靠。handle() 裡重新 App::setLocale()。多語系這個章節的核心 API 完全沒有版本差異,今天除了更新 Session + Middleware 持久化語系的範例寫法(改用 Day 16 建立的新版中介層註冊方式),也補上了日期數字在地化、複數形式的完整規則、兩種翻譯檔案格式的優先順序這幾個容易被入門教材忽略的細節。
入境問俗,入國問禁 — 《禮記・曲禮上》
Day 21 重新畫一次請求生命週期圖——那張經典的 Kernel.php 路徑圖,今天要用 bootstrap/app.php 為核心整個重畫過。